Skip to content

feat(core): WebKit plays bare audio from a decoded buffer on the audio clock - #6912

Merged
miguel-heygen merged 2 commits into
mainfrom
feat/webkit-audio-decode-first
Nov 1, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
feat/webkit-audio-decode-first

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

What

In WebKit, a bare <audio data-start> starts on its element as today and, as soon as its decoded buffer is ready, moves to an AudioBufferSourceNode on the AudioContext clock. The film clock then follows that context clock, so audio no longer has an element start delay, seeks or drift to tolerate. Follows #6888, which is merged.

Why

#6888 stops the seek loop by tolerating up to 0.65 s of audio lag, the same trade Remotion makes. WebKit restarts an element's output 150 to 400 ms after play() or a seek, so any element route has that lag. A buffer source starts at the exact context time and needs no element restart.

How

  • init.ts: decodesFirst(el) selects WebKit audio at rate 1 that does not loop and is at most 300 s (decode memory cap). After the element route is scheduled, the decoded buffer is scheduled at the playhead.
  • webAudioTransport.ts: releaseRouted(el) drops the element's routed source once the buffer plays; the element stays muted by the existing buffer path and is native again when the buffer ends.
  • Unchanged: non-unit rates and rate curves stay on the element (pitch), cross-origin audio without CORS stays native, Chrome and every non-WebKit browser, video.

Numbers

3-take edit, old Mac WebKit (WKWebView), media on the same machine, 5 plays each, interleaved.

audio seeks (median) frozen audio (median) playhead stops (per play)
main 5 12.3% 2, 2, 4, 5, 6
#6888 2 3.1% 0
this PR (element metric) 2 3.1% 0
Remotion 4.0 <Player> 3 0% 1, 1, 0, 1, 1

The seeks and frozen columns watch the audio element. In this PR the element is muted once the buffer plays, so they do not describe what is heard. What is heard, from the transport's own log over the same 5 plays:

  • the buffer takes over 33 to 37 ms after the element route starts (about 0.04 s of film time), against a 250 ms element start delay;
  • no audible restart in any play: 3 clips, 3 buffer sources, each scheduled once at its start on the context clock;
  • audio output latency reported by the context: 15.6 ms. With the context clock driving the film, that is the audio-to-picture offset, against up to 0.65 s with the element route.

Not measured: a loopback recording of the output, so the audible numbers above come from the transport log and the context's reported latency, not from captured sound. Safari proper (this is the same engine in a WKWebView) and iOS (gesture unlock follows the existing transport wake()).

Tests

  • fails without the fix: init.test.ts > "bare audio in WebKit" > "hands the sound to a decoded buffer on the context clock once it is decoded"
  • init.test.ts > "leaves the element on its route elsewhere" pins Chrome.
  • Core runtime suite: 112 files, 2693 tests pass; oxfmt and oxlint clean.

Size

100 changed lines, a separate and independently revertable behaviour on top of #6888, whose tolerance this removes in practice.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 94e28188 (stacked on #6888 at 81653923). Requesting changes for one regression. The handover itself looks right.

Blocker: a speed change mid-play pitch-shifts a handed-over track. The body says non-unit rates stay on the element to keep pitch, but that is only checked when the track is scheduled (state.playbackRate === 1 && decodesFirst(rawEl)). Once the buffer has taken over, a Studio speed change goes through applyWebAudioRate(), which calls webAudio.setRate(). For an unbounded source, setRate() just sets sourceNode.playbackRate in place, and an AudioBufferSourceNode does not preserve pitch. The stopAll() and reschedule (which would put it back on the element, since the rate is no longer 1) runs only when hasBoundedActiveSources() is true. A bare <audio data-start> with no data-duration and no bounding host has clipDuration = Infinity, so it is unbounded. That is the case this PR targets.

Repro: in Safari, play a composition whose only audio is a bare <audio data-start> music bed, wait for the handover (~35 ms), then set speed to 1.5×. With this PR the bed plays chipmunked. On #6888 it stays pitch-corrected on the element.

Suggested fix: when a source was handed over by decodesFirst, treat a rate change away from 1 like the bounded case (stopAll() and reschedule), so it goes back to the element. Please add a test: hand over, change rate, and expect the element route to be rescheduled with no buffer schedulePlayback call.

Checked and fine:

  • releaseRouted → dispose(routed) reconnects the element's source to destination, but schedulePlayback has already set el.muted = true, so you don't hear both. When the buffer ends, handleSourceEnded restores priorMuted as the body describes.
  • decodeAudioElement re-checks the generation and isPlaying before scheduling, and schedulePlayback checks the generation again after wake(), so a seek or pause during the decode does not leave a stray buffer.
  • fails without the fix: with init.ts reverted to the #6888 base, "hands the sound to a decoded buffer on the context clock once it is decoded" fails and the Chrome test passes. With the change, both pass.

Non-blocking:

  • !(el.duration > 300) lets an element whose duration is still NaN (metadata not loaded) through to a full decode, with no size check. Consider requiring Number.isFinite(el.duration).
  • The Safari audio and perf workflows were still running when I reviewed.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fixed at ca10d37: a speed change now sends a handed-over track back to its element (stopAll and reschedule, so decodesFirst fails at the new rate and no buffer is scheduled). The test hands over, sets 1.5x and expects the element route again with no buffer schedulePlayback; it fails without the init.ts condition. Also took the NaN-duration point: decodesFirst now needs a known duration of at most 300 s.

heygengenesis[bot]
heygengenesis Bot previously approved these changes Nov 1, 2026

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at ca10d370. Approving.

  • Blocker resolved: releaseRouted now marks the element as handed over, and applyWebAudioRate() stops and reschedules when hasHandedOverSources() is true, not only when bounded sources are active. The reschedule runs at the new rate, so decodesFirst is skipped (state.playbackRate !== 1) and the track goes back to its pitch-preserving element. stopAll() restores el.muted from the buffer source, and the element was still playing muted in sync, so it resumes where it should.
  • Duration gate: el.duration <= 300 now rejects a NaN duration, so no full decode starts before metadata has loaded.
  • Tests: init.test.ts and webAudioTransport.test.ts pass 388/388 at this head. With init.ts reverted to the previous head, "returns a handed-over track to its element when the speed changes, to hold its pitch" fails. The fails without the fix: test named in the body was verified at the previous head.
  • CI at this head: Comments, Player Safari audio, Player perf and preview-regression had passed; regression and Player Safari perf were still running.

Non-blocking:

  • The new test stubs hasHandedOverSources to return true, so it doesn't check that releaseRouted → _handedOver → a live buffer source actually reports true. A small WebAudioTransport unit test would pin that.
  • The body's fails without the fix: line could also name the new speed-change test.

Base automatically changed from fix/webkit-audio-seek-loop to main November 1, 2026 03:24
@miguel-heygen
miguel-heygen dismissed heygengenesis[bot]’s stale review November 1, 2026 03:24

The base branch was changed.

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 98 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (3)

  • seqplainkeys-none-center-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true
  • seqab-none-center-r0-root-z100: tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true
  • seqab-none-pct-r0-root-z100: tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen force-pushed the feat/webkit-audio-decode-first branch from ca10d37 to f75dfea Compare November 1, 2026 03:34

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at f75dfea0, the rebase onto main after #6888 merged. Approving: I confirmed this is a pure rebase.

  • Same patch: the merge-base with main is #6888's merge commit 2c1a10f3. The diff from that merge-base to f75dfea0 is byte-identical (ignoring index lines) to the diff I approved, 81653923..ca10d370.
  • Same base code: packages/core/src/runtime/ is identical between #6888's old head 81653923 and its merge commit 2c1a10f3, so the patch now sits on the same code it was reviewed against.
  • Tests: init.test.ts and webAudioTransport.test.ts pass 388/388 at this head.
  • CI: the reruns for this head were still in progress when I reviewed (Comments and PR captures had passed).

My earlier review of ca10d370 covers the code; its non-blocking notes still apply.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit e181d34 Nov 1, 2026
135 of 136 checks passed
@miguel-heygen
miguel-heygen deleted the feat/webkit-audio-decode-first branch November 1, 2026 04:09
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 6, 2026
…view to heygen-com#7250

Adopts heygen-com#7310 (MPEG-4 Part 2 / MJPEG get browser-playable copies), heygen-com#7316
(check_report counts the copies a check waited for) and heygen-com#7317 (render reads
a composition src like the preview and refuses paths outside the project).
Defers heygen-com#7309 (needs heygen-com#6912); heygen-com#7314 does not apply. PR axis advances to heygen-com#7250
(heygen-com#7243 closed unmerged, heygen-com#7249 open and pinned as monitored; monitored 96->97).

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant